feat: improve spawning, victory logic, and camera controls
This commit is contained in:
parent
75f65e7918
commit
1d0d791001
|
|
@ -31,7 +31,7 @@ Player 10</textarea>
|
||||||
<legend>Match</legend>
|
<legend>Match</legend>
|
||||||
<div class="team-size-row">
|
<div class="team-size-row">
|
||||||
<label for="team-size">팀당 인원</label>
|
<label for="team-size">팀당 인원</label>
|
||||||
<output id="team-size-value" for="team-size">5 vs 5</output>
|
<output id="team-size-value" for="team-size">5</output>
|
||||||
</div>
|
</div>
|
||||||
<input id="team-size" name="teamSize" type="range" min="1" max="100" value="5" />
|
<input id="team-size" name="teamSize" type="range" min="1" max="100" value="5" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -39,8 +39,11 @@ Player 10</textarea>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section class="arena-shell" aria-label="전투 경기장">
|
<section class="arena-shell" aria-label="전투 경기장">
|
||||||
|
<div id="scoreboard" class="scoreboard">
|
||||||
|
<div id="score-left" class="score-side left"></div>
|
||||||
|
<div id="score-right" class="score-side right"></div>
|
||||||
|
</div>
|
||||||
<div id="game"></div>
|
<div id="game"></div>
|
||||||
<div id="match-status" class="match-status" aria-live="polite"></div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,19 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.matchId = 0;
|
this.matchId = 0;
|
||||||
this.matchOver = false;
|
this.matchOver = false;
|
||||||
this.ready = 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 = [];
|
this.teams = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +42,24 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
this.cameras.main.setBackgroundColor("#282819");
|
this.cameras.main.setBackgroundColor("#282819");
|
||||||
drawArena(this);
|
drawArena(this);
|
||||||
createFighterAnimations(this, fighterManifest);
|
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.ready = true;
|
||||||
this.startMatch(this.getInitialMatchConfig());
|
this.startMatch(this.getInitialMatchConfig());
|
||||||
}
|
}
|
||||||
|
|
@ -60,33 +90,93 @@ export class ArenaScene extends Phaser.Scene {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setStatus(matchStatusText(this.teams));
|
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.cameras.main.zoom > 1) {
|
||||||
|
const aliveFighters = this.fighters.filter(f => !f.isDead);
|
||||||
if (this.matchOver) {
|
if (aliveFighters.length > 0) {
|
||||||
return;
|
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) => {
|
this.fighters.forEach((fighter) => {
|
||||||
updateFighter(this, fighter, time, () => this.finishMatch());
|
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 = `<span>${team.label}</span> <span>${aliveCount}</span>`;
|
||||||
|
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
scoreLeft.appendChild(teamEl);
|
||||||
|
} else {
|
||||||
|
scoreRight.appendChild(teamEl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
finishMatch() {
|
finishMatch() {
|
||||||
const livingTeams = new Set(
|
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
|
||||||
this.fighters.filter((fighter) => !fighter.isDead).map((fighter) => fighter.team.id),
|
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
|
||||||
);
|
|
||||||
|
|
||||||
if (livingTeams.size > 1) {
|
if (livingTeams.size > 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const winningTeam = this.teams.find((team) => livingTeams.has(team.id));
|
|
||||||
this.matchOver = true;
|
this.matchOver = true;
|
||||||
clearCombatObjects(this);
|
clearCombatObjects(this);
|
||||||
this.fighters.forEach((fighter) => fighter.body.setVelocity(0, 0));
|
this.fighters.forEach((fighter) => {
|
||||||
this.setStatus(`${winningTeam?.label ?? "Draw"} 승리`);
|
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("무승부!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,10 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
|
||||||
|
|
||||||
defender.isLocked = true;
|
defender.isLocked = true;
|
||||||
playAnimation(defender, "hurt");
|
playAnimation(defender, "hurt");
|
||||||
scene.cameras.main.shake(90, 0.002);
|
|
||||||
|
if (instantKill) {
|
||||||
|
scene.cameras.main.shake(90, 0.002);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttackRange(fighter) {
|
function getAttackRange(fighter) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export const GRID_SIZE = 16;
|
export const GRID_SIZE = 50;
|
||||||
export const TILE_SIZE = 64;
|
export const TILE_SIZE = 64;
|
||||||
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,34 +8,39 @@ import {
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
|
|
||||||
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
|
||||||
const shuffledNames = shuffle([...names]);
|
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||||
const teamSize = resolveTeamSize(shuffledNames.length, requestedTeamSize);
|
const teams = names.map((name, index) => ({
|
||||||
const teams = createTeams(shuffledNames.length, teamSize);
|
color: TEAM_COLORS[index % TEAM_COLORS.length],
|
||||||
const spawns = createRandomSpawnPoints(shuffledNames.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 {
|
return {
|
||||||
fighters: shuffledNames.map((name, index) => {
|
fighters,
|
||||||
const teamSlot = Math.floor(index / teamSize);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...spawns[index],
|
|
||||||
name,
|
|
||||||
team: teams[teamSlot],
|
|
||||||
teamIndex: index - teamSlot * teamSize,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
teams,
|
teams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchStatusText(teams) {
|
export function matchStatusText(teams) {
|
||||||
if (teams.length > 8) {
|
const teamSizes = teams.map((team) => team.size).join(", ");
|
||||||
const playerCount = teams.reduce((count, team) => count + team.size, 0);
|
return `${teams.length}팀 전투: ${teamSizes}`;
|
||||||
|
|
||||||
return `${teams.length}팀 전투: 참가자 ${playerCount}명`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${teams.length}팀 전투: ${teams.map((team) => team.size).join(" vs ")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTeams(playerCount, teamSize) {
|
function createTeams(playerCount, teamSize) {
|
||||||
|
|
|
||||||
|
|
@ -154,13 +154,74 @@ button:hover {
|
||||||
|
|
||||||
.arena-shell {
|
.arena-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: clamp(16px, 3vw, 36px);
|
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 {
|
#game {
|
||||||
width: min(100%, calc(100vh - 72px), 1080px);
|
width: min(100%, calc(100vh - 72px), 1080px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const nicknameLength = 18;
|
||||||
export function createMatchForm() {
|
export function createMatchForm() {
|
||||||
const form = getElement("#fighter-form");
|
const form = getElement("#fighter-form");
|
||||||
const namesInput = getElement("#player-names");
|
const namesInput = getElement("#player-names");
|
||||||
const statusNode = getElement("#match-status");
|
const statusNode = document.querySelector("#match-status");
|
||||||
const teamSizeInput = getElement("#team-size");
|
const teamSizeInput = getElement("#team-size");
|
||||||
const teamSizeOutput = getElement("#team-size-value");
|
const teamSizeOutput = getElement("#team-size-value");
|
||||||
|
|
||||||
|
|
@ -26,7 +26,9 @@ export function createMatchForm() {
|
||||||
},
|
},
|
||||||
readMatchConfig,
|
readMatchConfig,
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
statusNode.textContent = message;
|
if (statusNode) {
|
||||||
|
statusNode.textContent = message;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -49,5 +51,5 @@ function nicknameValues(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamSizeOutput(input, output) {
|
function syncTeamSizeOutput(input, output) {
|
||||||
output.textContent = `${input.value} vs ${input.value}`;
|
output.textContent = input.value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
todo.md
15
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개일 때 해당 닉네임 승리 표시.
|
||||||
|
- 생존자가 없을 경우 "무승부!"가 표시되도록 예외 처리 추가.
|
||||||
Loading…
Reference in New Issue