|
1 | 1 | <template> |
2 | | - <div class="bubble-game" :class="{ 'screen-shake': shaking }"> |
| 2 | + <div class="bubble-game" :class="{ 'screen-shake': shaking, 'bubble-flash': bubbleFlash }"> |
3 | 3 | <div class="crt-lines"></div> |
4 | 4 |
|
5 | 5 | <!-- ===== JOIN SCREEN ===== --> |
|
163 | 163 | </div> |
164 | 164 | </div> |
165 | 165 |
|
| 166 | + <!-- 상대 공격 경고 알림 --> |
| 167 | + <transition name="alert-slide"> |
| 168 | + <div v-if="incomingAlert" class="incoming-alert"> |
| 169 | + <span class="alert-text">{{ incomingAlert }}</span> |
| 170 | + </div> |
| 171 | + </transition> |
| 172 | + |
166 | 173 | <div class="monster-overlay"> |
| 174 | + <!-- 수신 버블: 날아와서 터짐 --> |
| 175 | + <div v-for="b in incomingBubbles" :key="'ib'+b.id" class="incoming-bubble" |
| 176 | + :class="{ 'ib-flying': b.phase === 'flying', 'ib-pop': b.phase === 'pop' }" |
| 177 | + :style="{ '--target-x': b.targetX + 'px', '--target-y': b.targetY + 'px', '--start-y': b.y + 'px' }"> |
| 178 | + <span class="ib-shell">🫧</span> |
| 179 | + <span class="ib-inner">👾</span> |
| 180 | + </div> |
167 | 181 | <div v-for="m in activeMonsters" :key="m.id" class="monster-bug" |
168 | 182 | :style="{ left: m.x + 'px', top: m.y + 'px', fontSize: m.size + 'rem' }">👾</div> |
169 | 183 | <transition-group name="bubble-fly" tag="div"> |
@@ -218,14 +232,17 @@ const termBody = ref(null) |
218 | 232 | const activeMonsters = ref([]) |
219 | 233 | const flyingBubbles = ref([]) |
220 | 234 | const comboPops = ref([]) |
221 | | -const maxMonsters = 25 |
| 235 | +const maxMonsters = 12 |
222 | 236 | const opponentMonsterCount = ref(0) |
223 | 237 | const isWinner = ref(false) |
224 | 238 | const combo = ref(0) |
225 | 239 | const bestCombo = ref(0) |
226 | 240 | const totalSolved = ref(0) |
227 | 241 | const totalBubblesSent = ref(0) |
228 | 242 | const shaking = ref(false) |
| 243 | +const incomingAlert = ref('') // 상대 공격 경고 |
| 244 | +const bubbleFlash = ref(false) |
| 245 | +let alertTimer = null |
229 | 246 | let flyBubbleId = 0 |
230 | 247 | let combPopId = 0 |
231 | 248 | let animFrameId = null |
@@ -318,7 +335,7 @@ function getChoiceClass(idx) { |
318 | 335 | } |
319 | 336 |
|
320 | 337 | function selectChoice(idx) { |
321 | | - if (answerState.value !== 'idle') return |
| 338 | + if (answerState.value !== 'idle' || gamePhase.value !== 'playing') return |
322 | 339 | selectedChoiceIdx.value = idx |
323 | 340 | const choice = currentProblem.value?.choices?.[idx] |
324 | 341 | if (!choice) return |
@@ -355,6 +372,51 @@ function selectChoice(idx) { |
355 | 372 | }, 1200) |
356 | 373 | } |
357 | 374 |
|
| 375 | +// 상대 버블 날아와서 터지면서 몬스터 스폰 |
| 376 | +const incomingBubbles = ref([]) |
| 377 | +let incomingBubbleId = 0 |
| 378 | +
|
| 379 | +function spawnIncomingBubble(monsterCount) { |
| 380 | + const id = ++incomingBubbleId |
| 381 | + const targetX = 100 + Math.random() * (window.innerWidth * 0.5) |
| 382 | + const targetY = 150 + Math.random() * (window.innerHeight * 0.4) |
| 383 | + incomingBubbles.value.push({ id, x: window.innerWidth + 50, y: targetY, targetX, targetY, phase: 'flying' }) |
| 384 | +
|
| 385 | + // 0.7초 후 도작 → 터짐 |
| 386 | + setTimeout(() => { |
| 387 | + const b = incomingBubbles.value.find(b => b.id === id) |
| 388 | + if (b) b.phase = 'pop' |
| 389 | + // 화면 빨간 플래시 |
| 390 | + bubbleFlash.value = true |
| 391 | + setTimeout(() => { bubbleFlash.value = false }, 400) |
| 392 | + // 터진 위치에서 몬스터 스폰 (버블 위치 기준) |
| 393 | + for (let i = 0; i < monsterCount; i++) { |
| 394 | + activeMonsters.value.push({ |
| 395 | + id: Date.now() + Math.random(), |
| 396 | + x: targetX + (Math.random() - 0.5) * 80, |
| 397 | + y: targetY + (Math.random() - 0.5) * 80, |
| 398 | + dx: (Math.random() - 0.5) * 3, dy: (Math.random() - 0.5) * 3, |
| 399 | + size: 1.5 + Math.random() * 0.8 |
| 400 | + }) |
| 401 | + } |
| 402 | + // 게임오버 판정 |
| 403 | + if (activeMonsters.value.length >= maxMonsters && gamePhase.value === 'playing') { |
| 404 | + gamePhase.value = 'gameover-pending' |
| 405 | + bs.emitGameOver(currentRoomId.value) |
| 406 | + } |
| 407 | + // 0.6초 후 버블 제거 |
| 408 | + setTimeout(() => { |
| 409 | + incomingBubbles.value = incomingBubbles.value.filter(b => b.id !== id) |
| 410 | + }, 600) |
| 411 | + }, 700) |
| 412 | +} |
| 413 | +
|
| 414 | +function showAlert(msg) { |
| 415 | + incomingAlert.value = msg |
| 416 | + if (alertTimer) clearTimeout(alertTimer) |
| 417 | + alertTimer = setTimeout(() => { incomingAlert.value = '' }, 1500) |
| 418 | +} |
| 419 | +
|
358 | 420 | function nextProblem() { |
359 | 421 | if (allProblems.value.length === 0) return |
360 | 422 | currentProblemIndex.value = (currentProblemIndex.value + 1) % allProblems.value.length |
@@ -384,7 +446,10 @@ function spawnMonsters(count) { |
384 | 446 | size: 1.5 + Math.random() * 0.8 |
385 | 447 | }) |
386 | 448 | } |
387 | | - if (activeMonsters.value.length >= maxMonsters) bs.emitGameOver(currentRoomId.value) |
| 449 | + if (activeMonsters.value.length >= maxMonsters && gamePhase.value === 'playing') { |
| 450 | + gamePhase.value = 'gameover-pending' // 즉시 입력 차단 |
| 451 | + bs.emitGameOver(currentRoomId.value) |
| 452 | + } |
388 | 453 | } |
389 | 454 |
|
390 | 455 | function startGameLoop() { |
@@ -439,8 +504,17 @@ onMounted(() => { |
439 | 504 | }, 800) |
440 | 505 | } |
441 | 506 |
|
442 | | - bs.onReceiveMonster.value = () => spawnMonsters(1) |
443 | | - bs.onReceiveFever.value = (data) => spawnMonsters(data.count || 3) |
| 507 | + bs.onReceiveMonster.value = () => { |
| 508 | + showAlert('⚠️ 상대 공격!') |
| 509 | + spawnIncomingBubble(1) |
| 510 | + } |
| 511 | + bs.onReceiveFever.value = (data) => { |
| 512 | + const cnt = data.count || 3 |
| 513 | + showAlert(`🔥 FEVER 공격! 몬스터 ${cnt}마리!`) |
| 514 | + shaking.value = true |
| 515 | + setTimeout(() => shaking.value = false, 600) |
| 516 | + spawnIncomingBubble(cnt) |
| 517 | + } |
444 | 518 |
|
445 | 519 | bs.onMonsterSync.value = (data) => { |
446 | 520 | if (!data?.counts || !bs.socket.value) return |
@@ -648,4 +722,44 @@ onUnmounted(() => { |
648 | 722 | .btn-retry { flex:1; max-width:180px; padding:.6rem; font-family:'Orbitron',sans-serif; font-size:.75rem; font-weight:700; background:transparent; border:2px solid #00f0ff; color:#00f0ff; border-radius:.6rem; cursor:pointer; transition:all .2s } |
649 | 723 | .btn-retry:hover { background:rgba(0,240,255,.1) } |
650 | 724 | .btn-exit { flex:1; max-width:180px; padding:.6rem; font-family:'Orbitron',sans-serif; font-size:.75rem; font-weight:700; background:transparent; border:1px solid #334155; color:#64748b; border-radius:.6rem; cursor:pointer } |
| 725 | +
|
| 726 | +/* 수신 버블 애니메이션 */ |
| 727 | +.incoming-bubble { position:absolute; z-index:35; font-size:5rem; pointer-events:none } |
| 728 | +.ib-shell { position:relative; z-index:1; filter:drop-shadow(0 0 20px rgba(0,240,255,.6)) } |
| 729 | +.ib-inner { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:2.2rem; z-index:2 } |
| 730 | +.ib-flying { animation: ib-fly .7s cubic-bezier(.2,.8,.3,1) forwards } |
| 731 | +.ib-pop .ib-shell { animation: ib-burst-shell .6s ease-out forwards } |
| 732 | +.ib-pop .ib-inner { animation: ib-burst-monster .6s ease-out forwards } |
| 733 | +.ib-pop::after { content:''; position:absolute; top:50%; left:50%; width:200px; height:200px; transform:translate(-50%,-50%); border-radius:50%; background:radial-gradient(circle, rgba(255,45,117,.5) 0%, rgba(255,45,117,0) 70%); animation: ib-shockwave .6s ease-out forwards; pointer-events:none } |
| 734 | +@keyframes ib-fly { |
| 735 | + 0% { right:-80px; top:var(--start-y); opacity:0; transform:scale(.5) } |
| 736 | + 20% { opacity:1; transform:scale(1.1) } |
| 737 | + 100% { left:var(--target-x); top:var(--target-y); opacity:1; transform:scale(1) } |
| 738 | +} |
| 739 | +@keyframes ib-burst-shell { |
| 740 | + 0% { transform:scale(1); opacity:1 } |
| 741 | + 30% { transform:scale(2.2); opacity:1; filter:brightness(3) drop-shadow(0 0 40px rgba(255,45,117,1)) } |
| 742 | + 100% { transform:scale(3); opacity:0 } |
| 743 | +} |
| 744 | +@keyframes ib-burst-monster { |
| 745 | + 0% { transform:translate(-50%,-50%) scale(1); opacity:1 } |
| 746 | + 30% { transform:translate(-50%,-50%) scale(1.8); opacity:1 } |
| 747 | + 100% { transform:translate(-50%,-50%) scale(.5); opacity:0 } |
| 748 | +} |
| 749 | +@keyframes ib-shockwave { |
| 750 | + 0% { width:0; height:0; opacity:1 } |
| 751 | + 100% { width:300px; height:300px; opacity:0 } |
| 752 | +} |
| 753 | +/* 버블 터질 때 화면 빨간 플래시 */ |
| 754 | +.bubble-flash::after { content:''; position:fixed; inset:0; background:rgba(255,45,117,.15); z-index:9000; pointer-events:none; animation:bflash .4s ease-out forwards } |
| 755 | +@keyframes bflash { 0%{opacity:1} 100%{opacity:0} } |
| 756 | +
|
| 757 | +/* 상대 공격 경고 알림 */ |
| 758 | +.incoming-alert { position:fixed; top:80px; left:50%; transform:translateX(-50%); z-index:50; background:rgba(255,45,117,.15); border:2px solid #ff2d75; border-radius:12px; padding:.5rem 1.5rem; backdrop-filter:blur(8px); box-shadow:0 0 30px rgba(255,45,117,.3); animation:alert-pulse .4s ease infinite alternate } |
| 759 | +.alert-text { font-family:'Orbitron',sans-serif; font-size:.85rem; font-weight:700; color:#ff2d75; letter-spacing:1px; text-shadow:0 0 10px rgba(255,45,117,.6) } |
| 760 | +@keyframes alert-pulse { from{box-shadow:0 0 15px rgba(255,45,117,.2)} to{box-shadow:0 0 40px rgba(255,45,117,.5)} } |
| 761 | +.alert-slide-enter-active { transition:all .3s ease-out } |
| 762 | +.alert-slide-leave-active { transition:all .4s ease-in } |
| 763 | +.alert-slide-enter-from { opacity:0; transform:translateX(-50%) translateY(-20px) } |
| 764 | +.alert-slide-leave-to { opacity:0; transform:translateX(-50%) translateY(-10px) } |
651 | 765 | </style> |
0 commit comments