diff --git a/index.html b/index.html index 2e12556..4106757 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@ Player 10 Match
- 5 vs 5 + 5
@@ -39,8 +39,11 @@ Player 10
+
+
+
+
-
diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js index 42523df..8e93767 100644 --- a/src/game/ArenaScene.js +++ b/src/game/ArenaScene.js @@ -16,7 +16,19 @@ export class ArenaScene extends Phaser.Scene { this.matchId = 0; this.matchOver = false; this.ready = false; - this.setStatus = setStatus; + this.setStatus = (message) => { + // 기존 배너 제거 + const oldBanner = document.querySelector(".victory-banner"); + if (oldBanner) oldBanner.remove(); + + // 승리 또는 무승부 메시지인 경우 전용 배너 생성 + if (message.includes("승리") || message.includes("무승부")) { + const banner = document.createElement("div"); + banner.className = "victory-banner"; + banner.textContent = message; + document.querySelector(".arena-shell").appendChild(banner); + } + }; this.teams = []; } @@ -30,6 +42,24 @@ export class ArenaScene extends Phaser.Scene { this.cameras.main.setBackgroundColor("#282819"); drawArena(this); createFighterAnimations(this, fighterManifest); + + // 미니맵 카메라 설정 + this.minimapCamera = this.cameras.add(10, 10, 150, 150).setZoom(150 / ARENA_SIZE).setName('minimap'); + this.minimapCamera.setBackgroundColor(0x000000); + this.minimapCamera.scrollX = 0; + this.minimapCamera.scrollY = 0; + this.minimapCamera.setAlpha(0); // 기본적으로는 숨김 + + // 마우스 휠로 줌 조절 + this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => { + const zoomStep = 0.1; + const newZoom = Phaser.Math.Clamp(this.cameras.main.zoom + (deltaY > 0 ? -zoomStep : zoomStep), 1, 3); + this.cameras.main.setZoom(newZoom); + + // 확대 시 미니맵 표시 + this.minimapCamera.setAlpha(newZoom > 1 ? 0.8 : 0); + }); + this.ready = true; this.startMatch(this.getInitialMatchConfig()); } @@ -60,33 +90,93 @@ export class ArenaScene extends Phaser.Scene { ); this.setStatus(matchStatusText(this.teams)); + this.updateScoreboard(); + } +update(time) { + this.fighters.forEach(syncFighterHud); + + if (this.matchOver) { + return; } - update(time) { - this.fighters.forEach(syncFighterHud); - - if (this.matchOver) { - return; + // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 + if (this.cameras.main.zoom > 1) { + const aliveFighters = this.fighters.filter(f => !f.isDead); + if (aliveFighters.length > 0) { + const avgX = aliveFighters.reduce((sum, f) => sum + f.x, 0) / aliveFighters.length; + const avgY = aliveFighters.reduce((sum, f) => sum + f.y, 0) / aliveFighters.length; + + // 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용 + const targetX = Math.round(avgX); + const targetY = Math.round(avgY); + + // 현재 카메라 위치에서 목표 위치로 서서히 이동 (0.1은 따라가는 속도) + this.cameras.main.scrollX += (targetX - this.cameras.main.centerX) * 0.1; + this.cameras.main.scrollY += (targetY - this.cameras.main.centerY) * 0.1; } + } else { + // 줌이 1일 때는 경기장 중앙에 고정 + this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); + } - this.fighters.forEach((fighter) => { - updateFighter(this, fighter, time, () => this.finishMatch()); + this.fighters.forEach((fighter) => { + updateFighter(this, fighter, time, () => { + this.updateScoreboard(); + this.finishMatch(); + }); + }); +} + + updateScoreboard() { + const scoreLeft = document.getElementById("score-left"); + const scoreRight = document.getElementById("score-right"); + + if (!scoreLeft || !scoreRight) return; + + scoreLeft.innerHTML = ""; + scoreRight.innerHTML = ""; + + this.teams.forEach((team, index) => { + const aliveCount = this.fighters.filter( + (f) => f.team.id === team.id && !f.isDead + ).length; + + const teamEl = document.createElement("div"); + teamEl.className = "team-score"; + teamEl.style.backgroundColor = `${team.color}44`; // 44 is alpha for 26% + teamEl.style.borderLeft = `4px solid ${team.color}`; + teamEl.innerHTML = `${team.label} ${aliveCount}`; + + if (index % 2 === 0) { + scoreLeft.appendChild(teamEl); + } else { + scoreRight.appendChild(teamEl); + } }); } finishMatch() { - const livingTeams = new Set( - this.fighters.filter((fighter) => !fighter.isDead).map((fighter) => fighter.team.id), - ); + const livingFighters = this.fighters.filter((fighter) => !fighter.isDead); + const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id)); if (livingTeams.size > 1) { return; } - const winningTeam = this.teams.find((team) => livingTeams.has(team.id)); this.matchOver = true; clearCombatObjects(this); - this.fighters.forEach((fighter) => fighter.body.setVelocity(0, 0)); - this.setStatus(`${winningTeam?.label ?? "Draw"} 승리`); + this.fighters.forEach((fighter) => { + if (fighter.body) { + fighter.body.setVelocity(0, 0); + } + }); + + if (livingTeams.size === 1) { + const winningTeamId = Array.from(livingTeams)[0]; + const winningTeam = this.teams.find((team) => team.id === winningTeamId); + this.setStatus(`${winningTeam?.label ?? "Unknown"} 승리!`); + } else { + this.setStatus("무승부!"); + } } } diff --git a/src/game/combat.js b/src/game/combat.js index ddc2258..0552b9f 100644 --- a/src/game/combat.js +++ b/src/game/combat.js @@ -213,7 +213,10 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = defender.isLocked = true; playAnimation(defender, "hurt"); - scene.cameras.main.shake(90, 0.002); + + if (instantKill) { + scene.cameras.main.shake(90, 0.002); + } } function getAttackRange(fighter) { diff --git a/src/game/config.js b/src/game/config.js index b1c926d..c810ab7 100644 --- a/src/game/config.js +++ b/src/game/config.js @@ -1,4 +1,4 @@ -export const GRID_SIZE = 16; +export const GRID_SIZE = 50; export const TILE_SIZE = 64; export const ARENA_SIZE = GRID_SIZE * TILE_SIZE; diff --git a/src/game/matchSetup.js b/src/game/matchSetup.js index e92c973..2d5d9e5 100644 --- a/src/game/matchSetup.js +++ b/src/game/matchSetup.js @@ -8,34 +8,39 @@ import { } from "./config.js"; export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) { - const shuffledNames = shuffle([...names]); - const teamSize = resolveTeamSize(shuffledNames.length, requestedTeamSize); - const teams = createTeams(shuffledNames.length, teamSize); - const spawns = createRandomSpawnPoints(shuffledNames.length); + const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE)); + const teams = names.map((name, index) => ({ + color: TEAM_COLORS[index % TEAM_COLORS.length], + id: `team-${index + 1}`, + label: name, + size: teamSize, + })); + + const totalFighters = names.length * teamSize; + const spawns = createRandomSpawnPoints(totalFighters); + + const fighters = []; + names.forEach((name, teamIndex) => { + for (let i = 0; i < teamSize; i++) { + const globalIndex = teamIndex * teamSize + i; + fighters.push({ + ...spawns[globalIndex], + name: name, + team: teams[teamIndex], + teamIndex: i, + }); + } + }); return { - fighters: shuffledNames.map((name, index) => { - const teamSlot = Math.floor(index / teamSize); - - return { - ...spawns[index], - name, - team: teams[teamSlot], - teamIndex: index - teamSlot * teamSize, - }; - }), + fighters, teams, }; } export function matchStatusText(teams) { - if (teams.length > 8) { - const playerCount = teams.reduce((count, team) => count + team.size, 0); - - return `${teams.length}팀 전투: 참가자 ${playerCount}명`; - } - - return `${teams.length}팀 전투: ${teams.map((team) => team.size).join(" vs ")}`; + const teamSizes = teams.map((team) => team.size).join(", "); + return `${teams.length}팀 전투: ${teamSizes}`; } function createTeams(playerCount, teamSize) { diff --git a/src/styles.css b/src/styles.css index 3deee51..a38b389 100644 --- a/src/styles.css +++ b/src/styles.css @@ -154,13 +154,74 @@ button:hover { .arena-shell { position: relative; - display: grid; - place-items: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; min-height: 100vh; overflow: hidden; padding: clamp(16px, 3vw, 36px); } +.scoreboard { + width: 100%; + display: flex; + justify-content: space-between; + padding: 0.5rem 1rem; + background: rgba(0, 0, 0, 0.5); + border-radius: 8px; + min-height: 40px; + pointer-events: none; + z-index: 10; +} + +.score-side { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + width: 48%; +} + +.score-side.right { + justify-content: flex-end; +} + +.team-score { + padding: 4px 10px; + border-radius: 4px; + font-size: 0.85rem; + font-weight: bold; + color: #fff; + text-shadow: 1px 1px 2px #000; + display: flex; + gap: 6px; + background: rgba(255, 255, 255, 0.1); +} + +.victory-banner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.85); + padding: 1.5rem 3rem; + border: 2px solid #d6a94a; + border-radius: 12px; + color: #fff7df; + font-size: 2rem; + font-weight: 900; + text-align: center; + z-index: 100; + box-shadow: 0 0 30px rgba(214, 169, 74, 0.4); + backdrop-filter: blur(4px); + animation: banner-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes banner-in { + from { transform: translate(-50%, -60%) scale(0.8); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} + #game { width: min(100%, calc(100vh - 72px), 1080px); aspect-ratio: 1; diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js index f1c7452..7ca7cad 100644 --- a/src/ui/matchForm.js +++ b/src/ui/matchForm.js @@ -3,7 +3,7 @@ const nicknameLength = 18; export function createMatchForm() { const form = getElement("#fighter-form"); const namesInput = getElement("#player-names"); - const statusNode = getElement("#match-status"); + const statusNode = document.querySelector("#match-status"); const teamSizeInput = getElement("#team-size"); const teamSizeOutput = getElement("#team-size-value"); @@ -26,7 +26,9 @@ export function createMatchForm() { }, readMatchConfig, setStatus(message) { - statusNode.textContent = message; + if (statusNode) { + statusNode.textContent = message; + } }, }; } @@ -49,5 +51,5 @@ function nicknameValues(value) { } function syncTeamSizeOutput(input, output) { - output.textContent = `${input.value} vs ${input.value}`; + output.textContent = input.value; } diff --git a/todo.md b/todo.md index e69de29..369a1c6 100644 --- a/todo.md +++ b/todo.md @@ -0,0 +1,15 @@ +1. 투사체 피격 판정이 너무 좋지않음 +- **원인 분석**: + - `src/game/combat.js`의 `PROJECTILE_HIT_RADIUS`가 8픽셀로 설정되어 있어 투사체의 물리적 크기에 비해 판정 범위가 좁습니다. + - 투사체가 생성될 때 공격자와 대상 사이의 거리가 매우 가까우면 투사체가 대상을 지나쳐버리는 현상이 발생할 수 있습니다. + - 현재 `projectilePathHitsDefender` 함수에서 투사체의 궤적(Line)과 대상의 히트박스(Rectangle) 충돌을 검사하고 있지만, 대상의 실제 충돌 영역(`defender.body`)의 위치와 크기가 애니메이션 프레임에 따라 미세하게 변하면서 판정이 어긋날 수 있습니다. + +2. 내가 이야기한 참가자 닉네임을 입력하고 팀당인원을 입력하면 참가자 닉네임 별 캐릭터가 스폰돼야해 (완료) +- **조치 사항**: + - `src/game/matchSetup.js`를 수정하여 입력된 각 닉네임을 독립된 팀으로 설정. + - 설정된 `teamSize`만큼 각 닉네임의 캐릭터가 소환되도록 로직 변경. + +3. 승리판정이 이상함 (완료) +- **조치 사항**: + - `src/game/ArenaScene.js`의 `finishMatch` 로직을 개선하여 생존 팀이 1개일 때 해당 닉네임 승리 표시. + - 생존자가 없을 경우 "무승부!"가 표시되도록 예외 처리 추가.