Add battle death stats and HUD updates

This commit is contained in:
2026-05-22 17:01:01 +09:00
parent be80cb17b0
commit a41e9b6f26
19 changed files with 2408 additions and 264 deletions
+49
View File
@@ -39,6 +39,8 @@ export const KILL_HEAL_EFFECT_FRAMES = 4;
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
export const KILL_GROWTH_MULTIPLIER = 1.25;
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
// 처치 성장 연출 tween 지속 시간(ms)입니다.
export const KILL_GROWTH_TWEEN_DURATION = 180;
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
@@ -152,3 +154,50 @@ export const TEAM_COLORS = [
"#63c5a6",
"#d98755",
];
const TEAM_COLOR_GOLDEN_ANGLE = 137.508;
const TEAM_COLOR_HUE_OFFSET = 12;
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
if (safeTeamCount <= TEAM_COLORS.length) {
return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
}
const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
const lightness =
TEAM_COLOR_LIGHTNESSES[
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length
];
return hslToHex(hue, saturation, lightness);
}
function hslToHex(hue, saturation, lightness) {
const normalizedSaturation = saturation / 100;
const normalizedLightness = lightness / 100;
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const match = normalizedLightness - chroma / 2;
const [red, green, blue] = huePrime < 1
? [chroma, x, 0]
: huePrime < 2
? [x, chroma, 0]
: huePrime < 3
? [0, chroma, x]
: huePrime < 4
? [0, x, chroma]
: huePrime < 5
? [x, 0, chroma]
: [chroma, 0, x];
return `#${[red, green, blue]
.map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0"))
.join("")}`;
}
+567 -20
View File
@@ -23,6 +23,7 @@ import { createFighter, syncFighterHud } from "./fighterFactory.js";
import { fighterManifest } from "./fighterManifest.js";
import { pickFighters } from "./fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
@@ -31,23 +32,35 @@ export class ArenaScene extends Phaser.Scene {
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
this.matchPaused = false;
this.presentationMode = true;
this.ready = false;
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
this.setStatus = (message) => {
// 기존 배너 제거
this.updateStatus(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);
document.querySelector(".arena-shell")?.appendChild(banner);
}
};
this.observedCombat = [];
this.selectedFighter = null;
this.teams = [];
this.killLogNode = null;
this.killLogListNode = null;
this.battleNoticeHideTimer = null;
this.battleNoticeNode = null;
this.battleNoticeSequence = 0;
this.battleNoticeTimer = null;
this.battleDeathCounts = createDeathCounts();
this.deathStatsBaseline = createDeathCounts();
this.deathStatsSaved = false;
}
preload() {
@@ -99,10 +112,10 @@ export class ArenaScene extends Phaser.Scene {
});
this.ready = true;
this.startMatch(this.getInitialMatchConfig());
this.startMatch(this.getInitialMatchConfig(), { silent: true });
}
startMatch({ names = [], teamSize } = {}) {
startMatch({ names = [], teamSize } = {}, { silent = false } = {}) {
if (!this.ready) {
return;
}
@@ -114,29 +127,257 @@ export class ArenaScene extends Phaser.Scene {
const matchSetup = createMatchSetup(names, teamSize);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins);
syncTeamSizes(matchSetup.teams, fighterPlans);
this.matchId += 1;
this.matchOver = false;
this.setPaused(false, { silent: true });
this.presentationMode = silent;
this.resetMatchDeathStats({ silent });
this.observedCombat = [];
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.destroy());
this.resetKillLog();
this.teams = matchSetup.teams;
this.fighters = matchSetup.fighters.map((fighterSetup, index) =>
createFighter(this, {
...fighterSetup,
skin: matchSkins[index],
}),
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
if (!silent) {
this.setStatus(matchStatusText(this.teams));
} else {
this.focusPresentationCombat();
}
this.updateScoreboard();
}
spawnSplitFighters(source, splitOnDeath) {
const count = Math.max(0, Math.round(splitOnDeath.count ?? 0));
const childMaxHp = Math.max(1, Math.round(splitOnDeath.childMaxHp ?? 1));
if (count === 0) {
return [];
}
const children = Array.from({ length: count }, (_, index) => {
const position = clusterSpawnPosition(source, index, count);
return createFighter(this, {
canSplitOnDeath: Boolean(splitOnDeath.childCanSplit),
faceLeft: source.flipX,
hp: childMaxHp,
maxHp: childMaxHp,
name: source.name,
skin: source.skin,
team: source.team,
teamIndex: source.teamIndex,
x: position.x,
y: position.y,
});
});
this.fighters.push(...children);
const team = this.teams.find((candidate) => candidate.id === source.team.id);
if (team) {
team.size += children.length;
}
return children;
}
resetKillLog() {
const { logNode, listNode } = this.getKillLogNodes();
if (listNode) {
listNode.replaceChildren();
}
logNode?.classList.remove("has-entries");
logNode?.setAttribute("aria-hidden", "true");
}
resetMatchDeathStats({ silent = false } = {}) {
this.clearBattleNotice();
this.battleDeathCounts = createDeathCounts();
this.battleNoticeSequence = 0;
this.deathStatsBaseline = createDeathCounts();
this.deathStatsSaved = false;
if (!silent) {
this.loadTodayDeathStats();
this.scheduleBattleNotice();
}
}
loadTodayDeathStats() {
const activeMatchId = this.matchId;
fetchTodayDeathStats()
.then((stats) => {
if (this.matchId !== activeMatchId || this.presentationMode) {
return;
}
this.deathStatsBaseline = normalizeDeathCounts(stats?.deathsBySpecies);
})
.catch((error) => {
console.warn(error);
});
}
scheduleBattleNotice(delayMs = BATTLE_NOTICE_DELAY_MS) {
this.battleNoticeTimer?.remove(false);
this.battleNoticeTimer = this.time.delayedCall(delayMs, () => {
this.battleNoticeTimer = null;
if (!this.matchOver && !this.presentationMode) {
this.showBattleDeathNotice();
}
});
}
clearBattleNotice() {
this.battleNoticeTimer?.remove(false);
this.battleNoticeHideTimer?.remove(false);
this.battleNoticeTimer = null;
this.battleNoticeHideTimer = null;
const noticeNode = this.getBattleNoticeNode();
noticeNode?.classList.remove("is-visible");
noticeNode?.setAttribute("aria-hidden", "true");
}
recordDeath(fighter) {
if (this.presentationMode) {
return;
}
const species = normalizeSpecies(fighter?.skin?.species);
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1;
}
showBattleDeathNotice() {
const noticeNode = this.getBattleNoticeNode();
if (!noticeNode) {
return;
}
noticeNode.textContent = createDeathNoticeMessage(
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
this.matchId + this.battleNoticeSequence,
);
this.battleNoticeSequence += 1;
noticeNode.classList.add("is-visible");
noticeNode.setAttribute("aria-hidden", "false");
this.battleNoticeHideTimer?.remove(false);
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
this.battleNoticeHideTimer = null;
noticeNode.classList.remove("is-visible");
noticeNode.setAttribute("aria-hidden", "true");
if (!this.matchOver && !this.presentationMode) {
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
}
});
}
getBattleNoticeNode() {
this.battleNoticeNode ??= document.getElementById("battle-notice");
return this.battleNoticeNode;
}
persistDailyDeathStats() {
if (this.deathStatsSaved || this.presentationMode) {
return;
}
this.deathStatsSaved = true;
addTodayDeathStats({
deathsBySpecies: { ...this.battleDeathCounts },
})
.then((result) => {
this.deathStatsBaseline = normalizeDeathCounts(result?.today?.deathsBySpecies);
})
.catch((error) => {
console.warn(error);
});
}
recordKill(winner, defender) {
if (this.presentationMode) {
return;
}
this.recordDeath(defender);
const { logNode, listNode } = this.getKillLogNodes();
if (!logNode || !listNode) {
return;
}
const item = document.createElement("li");
const killer = killLogFighterParts(winner);
const victim = killLogFighterParts(defender);
const action = document.createElement("span");
const weapon = document.createElement("span");
const actionText = document.createElement("span");
item.className = "kill-log-item";
item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
item.setAttribute(
"aria-label",
`${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
);
this.setStatus(matchStatusText(this.teams));
this.updateScoreboard();
action.className = "kill-log-action";
weapon.className = "kill-log-weapon";
weapon.setAttribute("aria-hidden", "true");
actionText.className = "kill-log-action-text";
actionText.textContent = "처치 >";
action.append(weapon, actionText);
item.append(
createKillLogFighterNode(killer, "killer"),
action,
createKillLogFighterNode(victim, "victim"),
);
listNode.append(item);
while (listNode.children.length > KILL_LOG_LIMIT) {
listNode.firstElementChild?.remove();
}
logNode.classList.add("has-entries");
logNode.setAttribute("aria-hidden", "false");
}
getKillLogNodes() {
this.killLogNode ??= document.getElementById("kill-log");
this.killLogListNode ??= document.getElementById("kill-log-list");
return {
logNode: this.killLogNode,
listNode: this.killLogListNode,
};
}
update(time) {
this.fighters.forEach(syncFighterHud);
if (this.matchPaused) {
this.updateMinimapViewportFrame();
return;
}
if (!this.matchOver) {
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
@@ -146,6 +387,13 @@ update(time) {
});
}
if (this.presentationMode) {
this.followPresentationCombat();
this.minimapCamera?.setAlpha(0);
this.updateMinimapViewportFrame();
return;
}
if (this.focusSelectedFighter()) {
this.updateMinimapViewportFrame();
return;
@@ -229,6 +477,109 @@ update(time) {
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
}
isMatchPaused() {
return this.matchPaused;
}
togglePause() {
return this.setPaused(!this.matchPaused);
}
setPaused(paused, { silent = false } = {}) {
const nextPaused = Boolean(paused) && this.ready && !this.matchOver && !this.presentationMode;
if (this.matchPaused === nextPaused) {
return this.matchPaused;
}
this.matchPaused = nextPaused;
if (nextPaused) {
this.physics.pause();
this.time.paused = true;
this.tweens.pauseAll?.();
} else {
this.physics.resume();
this.time.paused = false;
this.tweens.resumeAll?.();
}
this.setSceneAnimationsPaused(nextPaused);
if (!silent && !this.presentationMode) {
this.setStatus(nextPaused ? "일시정지" : matchStatusText(this.teams));
}
return this.matchPaused;
}
setSceneAnimationsPaused(paused) {
const animatedObjects = [
...this.fighters,
...(this.combatObjects ? Array.from(this.combatObjects) : []),
];
animatedObjects.forEach((object) => {
if (!object?.anims) {
return;
}
if (paused) {
object.anims.pause();
} else {
object.anims.resume();
}
});
}
selectRandomTeamFighter(teamId) {
if (this.matchOver) {
return;
}
const candidates = this.fighters.filter(
(fighter) => isLivingFighter(fighter) && fighter.team.id === teamId,
);
if (candidates.length === 0) {
return;
}
const fighter = candidates[Phaser.Math.Between(0, candidates.length - 1)];
this.selectFighter(fighter);
this.setStatus(`${fighter.team.label} 시점: ${fighter.fighterName ?? fighter.name}`);
this.updateScoreboard();
}
focusPresentationCombat() {
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
const combatCenter = this.getObservedCombatCenter();
if (combatCenter) {
this.cameras.main.centerOn(Math.round(combatCenter.x), Math.round(combatCenter.y));
}
this.minimapCamera?.setAlpha(0);
this.updateMinimapViewportFrame();
}
followPresentationCombat() {
if (this.cameras.main.zoom !== SPECTATOR_LATE_FIGHT_ZOOM) {
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
}
const combatCenter = this.getObservedCombatCenter();
if (!combatCenter) {
return;
}
this.cameras.main.scrollX +=
(Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
this.cameras.main.scrollY +=
(Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
}
setMainCameraZoom(zoom) {
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
@@ -315,22 +666,41 @@ update(time) {
scoreLeft.innerHTML = "";
scoreRight.innerHTML = "";
this.teams.forEach((team, index) => {
this.teams.forEach((team) => {
const aliveCount = this.fighters.filter(
(f) => f.team.id === team.id && !f.isDead
).length;
const teamEl = document.createElement("div");
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.style.backgroundColor = `${team.color}44`; // 44 is alpha for 26%
teamEl.type = "button";
teamEl.disabled = aliveCount === 0;
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
teamEl.style.setProperty("--team-color", team.color);
teamEl.style.backgroundColor = `${team.color}33`;
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);
if (this.selectedFighter?.team.id === team.id) {
teamEl.classList.add("is-focused");
}
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
labelEl.textContent = team.label;
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
countEl.textContent = `${aliveCount}`;
teamEl.addEventListener("click", () => {
this.selectRandomTeamFighter(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
scoreLeft.appendChild(teamEl);
});
}
@@ -350,6 +720,19 @@ update(time) {
}
});
if (this.presentationMode) {
const finishedMatchId = this.matchId;
this.time.delayedCall(1200, () => {
if (this.presentationMode && this.matchId === finishedMatchId) {
this.startMatch(this.getInitialMatchConfig(), { silent: true });
}
});
return;
}
this.clearBattleNotice();
this.persistDailyDeathStats();
if (livingTeams.size === 1) {
const winningTeamId = Array.from(livingTeams)[0];
const winningTeam = this.teams.find((team) => team.id === winningTeamId);
@@ -360,6 +743,170 @@ update(time) {
}
}
const KILL_LOG_LIMIT = 8;
const BATTLE_NOTICE_DELAY_MS = 5000;
const BATTLE_NOTICE_VISIBLE_MS = 2000;
const BATTLE_NOTICE_INTERVAL_MS = 10000;
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
const SPECIES_LABELS = {
bear: "곰",
human: "인간",
orc: "오크",
skeleton: "해골",
slime: "슬라임",
wolf: "늑대",
};
const SPECIES_SUBJECT_PARTICLES = {
bear: "이",
human: "이",
orc: "가",
skeleton: "이",
slime: "이",
wolf: "가",
};
const DEATH_NOTICE_TEMPLATES = [
"오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
"{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
function createDeathCounts() {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = 0;
return counts;
}, {});
}
function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
function addDeathCounts(baseCounts, matchCounts) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
return counts;
}, {});
}
function normalizeSpecies(value) {
return SPECIES_KEYS.includes(value) ? value : "human";
}
function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
const topSpecies = SPECIES_KEYS
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
.sort((left, right) => right.count - left.count)[0];
if (!topSpecies || topSpecies.count === 0) {
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
}
const template = DEATH_NOTICE_TEMPLATES[
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
];
return template
.replace("{species}", SPECIES_LABELS[topSpecies.species])
.replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
.replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
}
function createKillLogFighterNode(fighterParts, role) {
const container = document.createElement("span");
const avatar = document.createElement("span");
const copy = document.createElement("span");
const team = document.createElement("span");
const member = document.createElement("span");
container.className = `kill-log-fighter ${role}`;
avatar.className = "kill-log-avatar";
if (fighterParts.avatarUrl) {
avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
}
avatar.setAttribute("aria-hidden", "true");
copy.className = "kill-log-copy";
team.className = "kill-log-team";
team.textContent = fighterParts.teamLabel;
member.className = "kill-log-member";
member.textContent = fighterParts.memberLabel;
copy.append(team, member);
container.append(avatar, copy);
return container;
}
function killLogFighterParts(fighter) {
return {
teamLabel: fighter?.team?.label ?? "Unknown",
memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
avatarUrl: fighterSkinIdleUrl(fighter?.skin),
};
}
function fighterSkinIdleUrl(skin) {
const idleFile = skin?.animations?.idle?.file;
if (!skin?.assetRoot || !idleFile) {
return "";
}
return `${skin.assetRoot}/${idleFile}`;
}
function createFighterPlans(fighterSetups, skins) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1));
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
return {
...fighterSetup,
skin,
x: position.x,
y: position.y,
};
});
});
}
function clusterSpawnPosition(origin, index, count) {
if (count <= 1 || index === 0) {
return {
x: origin.x,
y: origin.y,
};
}
const ring = Math.ceil(index / 6);
const radius = SPAWN_CLUSTER_STEP * ring;
const angle = index * GOLDEN_ANGLE;
return {
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
};
}
function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
}
function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
});
}
function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
+48 -1
View File
@@ -1,5 +1,6 @@
import Phaser from "phaser";
import {
ARENA_SIZE,
ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN,
@@ -7,6 +8,7 @@ import {
FIGHTER_MAX_HP,
FIGHTER_SCALE,
KILL_HEALTH_RECOVERY_RATIO,
KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER,
KILL_GROWTH_TWEEN_DURATION,
MELEE_HIT_DELAY,
@@ -351,14 +353,37 @@ function killFighter(defender, winner, onWinner) {
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
maybeSplitFighter(defender);
onWinner(winner);
}
function maybeSplitFighter(fighter) {
const splitOnDeath = fighter.canSplitOnDeath === false
? null
: fighter.skin.traits?.splitOnDeath;
if (!splitOnDeath || typeof fighter.scene.spawnSplitFighters !== "function") {
return;
}
const chance = Phaser.Math.Clamp(Number(splitOnDeath.chance ?? 1), 0, 1);
if (Math.random() >= chance) {
return;
}
fighter.scene.spawnSplitFighters(fighter, splitOnDeath);
}
function applyKillReward(winner) {
winner.killCount = (winner.killCount ?? 0) + 1;
const rewardMultiplier = KILL_GROWTH_MULTIPLIER ** winner.killCount;
const rewardMultiplier = Math.min(
KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER ** winner.killCount,
);
const previousHp = winner.hp;
const nextHp = recoveredHealth(winner);
winner.killRewardMultiplier = rewardMultiplier;
@@ -377,9 +402,31 @@ function applyKillReward(winner) {
scaleY: nextScaleY,
duration: KILL_GROWTH_TWEEN_DURATION,
ease: "Back.Out",
onUpdate: () => clampFighterInsideArena(winner),
onComplete: () => clampFighterInsideArena(winner),
});
}
function clampFighterInsideArena(fighter) {
if (!fighter?.active || !fighter.body) {
return;
}
const halfWidth = Math.min(
ARENA_SIZE / 2,
Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
);
const halfHeight = Math.min(
ARENA_SIZE / 2,
Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
);
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA_SIZE - halfWidth);
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA_SIZE - halfHeight);
fighter.setPosition(x, y);
fighter.body.updateFromGameObject?.();
}
function recoveredHealth(fighter) {
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
+3 -6
View File
@@ -5,10 +5,7 @@ import {
KILL_HEAL_EFFECT_FRAME_RATE,
KILL_HEAL_EFFECT_FRAMES,
SELECTED_FIGHTER_OUTLINE_ALPHA,
SELECTED_FIGHTER_OUTLINE_BLUE,
SELECTED_FIGHTER_OUTLINE_GAP,
SELECTED_FIGHTER_OUTLINE_GREEN,
SELECTED_FIGHTER_OUTLINE_RED,
SELECTED_FIGHTER_OUTLINE_WIDTH,
} from "../constants.js";
@@ -261,9 +258,9 @@ function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
const outlineIndex = maskIndex * 4;
outlineData[outlineIndex] = SELECTED_FIGHTER_OUTLINE_RED;
outlineData[outlineIndex + 1] = SELECTED_FIGHTER_OUTLINE_GREEN;
outlineData[outlineIndex + 2] = SELECTED_FIGHTER_OUTLINE_BLUE;
outlineData[outlineIndex] = 255;
outlineData[outlineIndex + 1] = 255;
outlineData[outlineIndex + 2] = 255;
outlineData[outlineIndex + 3] = outlineAlpha;
}
}
+35 -18
View File
@@ -17,9 +17,21 @@ import {
const NAME_LABEL_BOTTOM_GAP = 14;
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
export function createFighter(
scene,
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
const displayName = name || team.label;
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? skin.stats?.maxHp ?? FIGHTER_MAX_HP));
const resolvedHp = Math.min(
resolvedMaxHp,
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
);
fighter.setScale(FIGHTER_SCALE);
fighter.setName(displayName);
fighter.setDepth(2);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
@@ -35,14 +47,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
Phaser.Geom.Rectangle.Contains,
);
fighter.input.cursor = "pointer";
fighter.selectionOutline = scene.add
fighter.teamMarker = scene.add
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
.setTint(teamColor)
.setAlpha(0.8)
.setDepth(1.9)
.setVisible(false);
.setVisible(true);
fighter.nameLabel = scene.add
.text(x, y, name, {
.text(x, y, displayName, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
@@ -61,15 +76,17 @@ export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x,
.setDepth(5);
fighter.skin = skin;
fighter.fighterName = displayName;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.baseScaleX = FIGHTER_SCALE;
fighter.baseScaleY = FIGHTER_SCALE;
fighter.canSplitOnDeath = canSplitOnDeath;
fighter.isSelected = false;
fighter.killCount = 0;
fighter.killRewardMultiplier = 1;
fighter.maxHp = FIGHTER_MAX_HP;
fighter.hp = fighter.maxHp;
fighter.maxHp = resolvedMaxHp;
fighter.hp = resolvedHp;
fighter.nextAttackAt = 0;
fighter.isLocked = false;
fighter.isDead = false;
@@ -102,18 +119,18 @@ export function syncFighterHud(fighter) {
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
syncSelectionOutline(fighter);
syncTeamMarker(fighter);
}
function syncSelectionOutline(fighter) {
const outline = fighter.selectionOutline;
function syncTeamMarker(fighter) {
const marker = fighter.teamMarker;
if (!outline) {
if (!marker) {
return;
}
const isVisible = Boolean(fighter.isSelected && !fighter.isDead);
outline.setVisible(isVisible);
const isVisible = Boolean(fighter.active && !fighter.isDead);
marker.setVisible(isVisible);
if (!isVisible) {
return;
@@ -122,20 +139,20 @@ function syncSelectionOutline(fighter) {
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
if (fighter.scene.textures.exists(outlineTextureKey)) {
outline.setTexture(outlineTextureKey, fighter.frame.name);
marker.setTexture(outlineTextureKey, fighter.frame.name);
}
outline.setPosition(fighter.x, fighter.y);
outline.setScale(fighter.scaleX, fighter.scaleY);
outline.setFlipX(fighter.flipX);
outline.setDepth(fighter.depth - 0.1);
marker.setPosition(fighter.x, fighter.y);
marker.setScale(fighter.scaleX, fighter.scaleY);
marker.setFlipX(fighter.flipX);
marker.setDepth(fighter.depth - 0.1);
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
fighter.selectionOutline.destroy();
fighter.teamMarker.destroy();
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();
+33
View File
@@ -4,6 +4,7 @@ export const fighterManifest = [
{
key: "knight",
label: "Knight",
species: "human",
assetRoot: "assets/characters/knight",
animations: {
idle: animation("Knight-Idle.png", 6),
@@ -19,6 +20,7 @@ export const fighterManifest = [
{
key: "orc",
label: "Orc",
species: "orc",
assetRoot: "assets/characters/orc",
animations: {
idle: animation("Orc-Idle.png", 6),
@@ -32,6 +34,7 @@ export const fighterManifest = [
{
key: "archer",
label: "Archer",
species: "human",
assetRoot: "assets/characters/archer",
combat: {
projectile: {
@@ -51,6 +54,7 @@ export const fighterManifest = [
{
key: "armored-axeman",
label: "Armored Axeman",
species: "human",
assetRoot: "assets/characters/armored-axeman",
animations: {
idle: animation("Armored Axeman-Idle.png", 6),
@@ -65,6 +69,7 @@ export const fighterManifest = [
{
key: "armored-orc",
label: "Armored Orc",
species: "orc",
assetRoot: "assets/characters/armored-orc",
animations: {
idle: animation("Armored Orc-Idle.png", 6),
@@ -80,6 +85,7 @@ export const fighterManifest = [
{
key: "armored-skeleton",
label: "Armored Skeleton",
species: "skeleton",
assetRoot: "assets/characters/armored-skeleton",
animations: {
idle: animation("Armored Skeleton-Idle.png", 6),
@@ -93,6 +99,7 @@ export const fighterManifest = [
{
key: "elite-orc",
label: "Elite Orc",
species: "orc",
assetRoot: "assets/characters/elite-orc",
animations: {
idle: animation("Elite Orc-Idle.png", 6),
@@ -107,6 +114,7 @@ export const fighterManifest = [
{
key: "greatsword-skeleton",
label: "Greatsword Skeleton",
species: "skeleton",
assetRoot: "assets/characters/greatsword-skeleton",
animations: {
idle: animation("Greatsword Skeleton-Idle.png", 6),
@@ -121,6 +129,7 @@ export const fighterManifest = [
{
key: "knight-templar",
label: "Knight Templar",
species: "human",
assetRoot: "assets/characters/knight-templar",
animations: {
idle: animation("Knight Templar-Idle.png", 6),
@@ -137,6 +146,7 @@ export const fighterManifest = [
{
key: "lancer",
label: "Lancer",
species: "human",
assetRoot: "assets/characters/lancer",
animations: {
idle: animation("Lancer-Idle.png", 6),
@@ -152,6 +162,7 @@ export const fighterManifest = [
{
key: "orc-rider",
label: "Orc rider",
species: "orc",
assetRoot: "assets/characters/orc-rider",
animations: {
idle: animation("Orc rider-Idle.png", 6),
@@ -167,6 +178,7 @@ export const fighterManifest = [
{
key: "priest",
label: "Priest",
species: "human",
assetRoot: "assets/characters/priest",
combat: {
attackEffect: {
@@ -187,6 +199,7 @@ export const fighterManifest = [
{
key: "skeleton",
label: "Skeleton",
species: "skeleton",
assetRoot: "assets/characters/skeleton",
animations: {
idle: animation("Skeleton-Idle.png", 6),
@@ -201,6 +214,7 @@ export const fighterManifest = [
{
key: "skeleton-archer",
label: "Skeleton Archer",
species: "skeleton",
assetRoot: "assets/characters/skeleton-archer",
combat: {
projectile: {
@@ -219,7 +233,20 @@ export const fighterManifest = [
{
key: "slime",
label: "Slime",
species: "slime",
assetRoot: "assets/characters/slime",
stats: {
maxHp: 1,
},
traits: {
spawnMultiplier: 10,
splitOnDeath: {
chance: 0.5,
count: 2,
childMaxHp: 1,
childCanSplit: false,
},
},
animations: {
idle: animation("Slime-Idle.png", 6),
walk: animation("Slime-Walk.png", 6),
@@ -232,6 +259,7 @@ export const fighterManifest = [
{
key: "soldier-close",
label: "Soldier Close",
species: "human",
assetRoot: "assets/characters/soldier",
combat: {
type: "melee",
@@ -248,6 +276,7 @@ export const fighterManifest = [
{
key: "soldier-range",
label: "Soldier Range",
species: "human",
assetRoot: "assets/characters/soldier",
combat: {
projectile: {
@@ -266,6 +295,7 @@ export const fighterManifest = [
{
key: "swordsman",
label: "Swordsman",
species: "human",
assetRoot: "assets/characters/swordsman",
animations: {
idle: animation("Swordsman-Idle.png", 6),
@@ -280,6 +310,7 @@ export const fighterManifest = [
{
key: "werebear",
label: "Werebear",
species: "bear",
assetRoot: "assets/characters/werebear",
animations: {
idle: animation("Werebear-Idle.png", 6),
@@ -294,6 +325,7 @@ export const fighterManifest = [
{
key: "werewolf",
label: "Werewolf",
species: "wolf",
assetRoot: "assets/characters/werewolf",
animations: {
idle: animation("Werewolf-Idle.png", 6),
@@ -307,6 +339,7 @@ export const fighterManifest = [
{
key: "wizard",
label: "Wizard",
species: "human",
assetRoot: "assets/characters/wizard",
combat: {
attackEffect: {
+12 -6
View File
@@ -2,15 +2,15 @@ import {
ARENA_SIZE,
DEFAULT_TEAM_SIZE,
GRID_SIZE,
getTeamColor,
MAX_TEAM_SIZE,
TEAM_COLORS,
TILE_SIZE,
} from "../constants.js";
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
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],
color: getTeamColor(index, names.length),
id: `team-${index + 1}`,
label: name,
size: teamSize,
@@ -39,13 +39,19 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
}
export function matchStatusText(teams) {
const teamSizes = teams.map((team) => team.size).join(", ");
return `${teams.length}팀 전투: ${teamSizes}`;
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const teamSizes = new Set(teams.map((team) => team.size));
const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}` : "팀별 가변 인원";
const labels = teams.map((team) => `${team.label} ${team.size}`).join(" / ");
return `${teams.length}팀 | ${teamSizeText} | 총 ${totalFighters}명 출전 | ${labels}`;
}
function createTeams(playerCount, teamSize) {
return Array.from({ length: Math.ceil(playerCount / teamSize) }, (_, index) => ({
color: TEAM_COLORS[index % TEAM_COLORS.length],
const teamCount = Math.ceil(playerCount / teamSize);
return Array.from({ length: teamCount }, (_, index) => ({
color: getTeamColor(index, teamCount),
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
+120 -2
View File
@@ -3,9 +3,123 @@ import { ArenaScene } from "./game/ArenaScene.js";
import { ARENA_SIZE } from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js";
import { trackVisitor } from "./ui/visitorCounter.js";
import "./styles.css";
const matchForm = createMatchForm();
const appNode = document.querySelector("#app");
const startButton = document.querySelector("#start-button");
const drawer = document.querySelector("#fighter-entry");
const drawerCloseButton = document.querySelector("#drawer-close");
const drawerScrim = document.querySelector("#drawer-scrim");
const drawerToggleButton = document.querySelector("#drawer-toggle");
const playerNamesInput = document.querySelector("#player-names");
const pauseButton = document.querySelector("#pause-button");
const restartButton = document.querySelector("#restart-button");
function isMatchLive() {
return appNode?.classList.contains("match-live") ?? false;
}
function openOptionsDrawer({ focus = true } = {}) {
appNode?.classList.add("options-open");
setDrawerCollapsed(false);
drawer?.setAttribute("aria-hidden", "false");
startButton?.setAttribute("aria-expanded", "true");
if (focus) {
window.setTimeout(() => playerNamesInput?.focus(), 220);
}
}
function closeOptionsDrawer() {
if (isMatchLive()) {
setDrawerCollapsed(true);
return;
}
appNode?.classList.remove("options-open");
appNode?.classList.remove("drawer-collapsed");
drawer?.setAttribute("aria-hidden", "true");
startButton?.setAttribute("aria-expanded", "false");
syncDrawerToggleButton();
}
function startConfiguredMatch(matchConfig) {
if (matchConfig.names.length < 2) {
matchForm.setStatus("참가자 닉네임을 2명 이상 입력하세요");
return;
}
appNode?.classList.add("match-live");
openOptionsDrawer({ focus: false });
arenaScene.startMatch(matchConfig);
syncPauseButton();
}
function setDrawerCollapsed(collapsed) {
const nextCollapsed = Boolean(collapsed) && isMatchLive();
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
drawer?.setAttribute("aria-hidden", "false");
syncDrawerToggleButton();
}
function syncDrawerToggleButton() {
if (!drawerToggleButton) {
return;
}
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
drawerToggleButton.textContent = isCollapsed ? "옵션 펼치기" : "옵션 접기";
drawerToggleButton.setAttribute("aria-expanded", String(!isCollapsed));
}
function syncPauseButton() {
if (!pauseButton) {
return;
}
const isPaused = arenaScene.isMatchPaused();
appNode?.classList.toggle("match-paused", isPaused);
pauseButton.textContent = isPaused ? "계속" : "일시정지";
pauseButton.setAttribute("aria-pressed", String(isPaused));
}
function revealAppWhenStylesAreReady() {
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
const reveal = () => {
window.requestAnimationFrame(() => {
document.documentElement.classList.remove("app-booting");
});
};
if (!stylesheet || stylesheet.sheet) {
reveal();
return;
}
stylesheet.addEventListener("load", reveal, { once: true });
}
startButton?.addEventListener("click", openOptionsDrawer);
drawerCloseButton?.addEventListener("click", closeOptionsDrawer);
drawerScrim?.addEventListener("click", closeOptionsDrawer);
drawerToggleButton?.addEventListener("click", () => {
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
setDrawerCollapsed(!isCollapsed);
});
pauseButton?.addEventListener("click", () => {
arenaScene.togglePause();
syncPauseButton();
});
restartButton?.addEventListener("click", () => {
startConfiguredMatch(matchForm.readMatchConfig());
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeOptionsDrawer();
}
});
const arenaScene = new ArenaScene({
getInitialMatchConfig: matchForm.readMatchConfig,
setStatus: matchForm.setStatus,
@@ -31,7 +145,11 @@ const game = new Phaser.Game({
scene: arenaScene,
});
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig));
revealAppWhenStylesAreReady();
matchForm.onSubmit((matchConfig) => {
startConfiguredMatch(matchConfig);
});
const visitorCountNode = document.querySelector("#visitor-count");
+1055 -142
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
export async function fetchTodayDeathStats() {
const response = await fetch("/api/death-stats/today", {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Death stats fetch failed: ${response.status}`);
}
return response.json();
}
export async function addTodayDeathStats(deathStats) {
const response = await fetch("/api/death-stats/today", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(deathStats),
});
if (!response.ok) {
throw new Error(`Death stats update failed: ${response.status}`);
}
return response.json();
}
+10 -1
View File
@@ -3,7 +3,9 @@ import { NICKNAME_LENGTH } from "../constants.js";
export function createMatchForm() {
const form = getElement("#fighter-form");
const namesInput = getElement("#player-names");
const appNode = document.querySelector("#app");
const statusNode = document.querySelector("#match-status");
const statusTextNodes = document.querySelectorAll("[data-status-text]");
const teamSizeInput = getElement("#team-size");
const teamSizeOutput = getElement("#team-size-value");
@@ -27,8 +29,15 @@ export function createMatchForm() {
readMatchConfig,
setStatus(message) {
if (statusNode) {
statusNode.textContent = message;
statusNode.setAttribute("aria-hidden", "false");
statusNode.title = message;
}
statusTextNodes.forEach((node) => {
node.textContent = message;
});
appNode?.classList.add("status-active");
},
};
}